iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 8

子類別應該能完美替換父類別 - 里氏替換原則 (Liskov Substitution Principle)

  • 分享至 

  • xImage
  •  

簡單介紹

今天我們來談談 SOLID 原則中的第三個原則 —— 里氏替換原則(Liskov Substitution Principle, LSP)。這個原則由麻省理工學院的 Barbara Liskov 教授在 1987 年提出,是物件導向設計中非常重要的概念。

里氏替換原則的核心思想是:

任何基類別可以出現的地方,子類別一定可以出現,而且替換後程式的行為不應該發生變化

換句話說,子類別必須能夠完美替換其父類別,而不破壞程式的正確性。這個原則確保了繼承關係的合理性,也是多態性能夠正常運作的基礎。

值得一提的是,違反里氏替換原則的設計往往會導致程式在執行時產生意外的行為,特別是當我們使用多態性時。這種問題在編譯時期通常不會被發現,只有在執行時期才會暴露出來。

接下來我們會透過一個經典的「鳥類飛行」例子來了解這個原則,並學習如何避免常見的設計陷阱。

違反里氏替換原則(不良):子類別破壞了父類別的行為契約

// 不好的範例:違反 LSP 的鳥類設計
abstract class Bird {
  abstract fly(): string;
  abstract makeSound(): string;
  
  // 所有鳥類的共同行為
  eat(): string {
    return "正在進食...";
  }
}

// 一般會飛的鳥
class Sparrow extends Bird {
  fly(): string {
    return "麻雀正在飛翔中...";
  }
  
  makeSound(): string {
    return "嘰嘰喳喳";
  }
}

class Eagle extends Bird {
  fly(): string {
    return "老鷹正在高空翱翔...";
  }
  
  makeSound(): string {
    return "啾啾";
  }
}

// 問題來了:企鵝不會飛!
class Penguin extends Bird {
  fly(): string {
    // 企鵝不會飛,但被迫要實作 fly 方法
    // 這違反了里氏替換原則!
    throw new Error("企鵝不會飛!");
  }
  
  makeSound(): string {
    return "嘎嘎";
  }
  
  // 企鵝特有的行為
  swim(): string {
    return "企鵝正在游泳...";
  }
}

// 鳥類管理系統
class BirdManager {
  makeBirdsFly(birds: Bird[]): string[] {
    const results: string[] = [];
    
    for (const bird of birds) {
      try {
        // 這裡假設所有 Bird 都能飛
        // 但當 bird 是 Penguin 時就會拋出例外!
        results.push(bird.fly());
      } catch (error) {
        results.push(`錯誤:${error.message}`);
      }
    }
    
    return results;
  }
}

// 使用範例 - 會產生問題
const birdManager = new BirdManager();
const birds: Bird[] = [
  new Sparrow(),
  new Eagle(), 
  new Penguin() // 這隻企鵝會破壞程式的正常運作
];

const flyingResults = birdManager.makeBirdsFly(birds);
console.log(flyingResults);
// 輸出:
// ["麻雀正在飛翔中...", "老鷹正在高空翱翔...", "錯誤:企鵝不會飛!"]

問題分析

上面的設計違反了里氏替換原則,主要問題包括:

  1. 行為不一致Penguinfly() 方法拋出例外,與其他 Bird 子類別的行為完全不同
  2. 破壞多態性:當使用 Bird 陣列時,無法安全地呼叫 fly() 方法
  3. 強制實作不適用的方法:企鵝被迫實作 fly() 方法,但這並不符合其天性
  4. 執行時期錯誤:問題在編譯時期無法發現,只能在執行時期暴露

符合里氏替換原則(修正):重新設計繼承結構,確保子類別行為一致

// 修正範例:符合 LSP 的鳥類設計
abstract class Bird {
  abstract makeSound(): string;
  
  eat(): string {
    return "正在進食...";
  }
}

// 會飛的鳥類介面
interface Flyable {
  fly(): string;
  getMaxAltitude(): number;
}

// 會游泳的鳥類介面
interface Swimmable {
  swim(): string;
  getMaxDepth(): number;
}

// 會飛的鳥類
class FlyingBird extends Bird implements Flyable {
  constructor(protected species: string) {
    super();
  }
  
  fly(): string {
    return `${this.species}正在飛翔中...`;
  }
  
  getMaxAltitude(): number {
    return 1000; // 預設最大飛行高度
  }
  
  makeSound(): string {
    return "鳥鳴聲";
  }
}

// 不會飛但會游泳的鳥類
class SwimmingBird extends Bird implements Swimmable {
  constructor(protected species: string) {
    super();
  }
  
  swim(): string {
    return `${this.species}正在游泳...`;
  }
  
  getMaxDepth(): number {
    return 50; // 預設最大游泳深度
  }
  
  makeSound(): string {
    return "鳥鳴聲";
  }
}

// 具體的鳥類實作
class Sparrow extends FlyingBird {
  constructor() {
    super("麻雀");
  }
  
  makeSound(): string {
    return "嘰嘰喳喳";
  }
  
  getMaxAltitude(): number {
    return 500; // 麻雀飛行高度較低
  }
}

class Eagle extends FlyingBird {
  constructor() {
    super("老鷹");
  }
  
  makeSound(): string {
    return "啾啾";
  }
  
  getMaxAltitude(): number {
    return 3000; // 老鷹可以飛得很高
  }
}

class Penguin extends SwimmingBird {
  constructor() {
    super("企鵝");
  }
  
  makeSound(): string {
    return "嘎嘎";
  }
  
  getMaxDepth(): number {
    return 200; // 企鵝可以潛水很深
  }
}

// 改進後的鳥類管理系統
class ImprovedBirdManager {
  // 讓會飛的鳥飛翔
  makeFlyingBirdsFly(flyingBirds: Flyable[]): string[] {
    return flyingBirds.map(bird => {
      const flyMessage = bird.fly();
      const altitude = bird.getMaxAltitude();
      return `${flyMessage} (最大飛行高度:${altitude}m)`;
    });
  }
  
  // 讓會游泳的鳥游泳
  makeSwimmingBirdsSwim(swimmingBirds: Swimmable[]): string[] {
    return swimmingBirds.map(bird => {
      const swimMessage = bird.swim();
      const depth = bird.getMaxDepth();
      return `${swimMessage} (最大潛水深度:${depth}m)`;
    });
  }
  
  // 讓所有鳥類發出聲音
  makeBirdsSound(birds: Bird[]): string[] {
    return birds.map(bird => {
      const sound = bird.makeSound();
      return `鳥類發出聲音:${sound}`;
    });
  }
}

// 使用範例 - 現在可以安全運作了
const improvedManager = new ImprovedBirdManager();

const sparrow = new Sparrow();
const eagle = new Eagle();
const penguin = new Penguin();

// 所有鳥類都可以發出聲音,符合 LSP
const allBirds: Bird[] = [sparrow, eagle, penguin];
const soundResults = improvedManager.makeBirdsSound(allBirds);
console.log("所有鳥類的聲音:");
soundResults.forEach(result => console.log(result));

// 只有會飛的鳥才會被要求飛翔
const flyingBirds: Flyable[] = [sparrow, eagle];
const flyingResults = improvedManager.makeFlyingBirdsFly(flyingBirds);
console.log("\n會飛的鳥類:");
flyingResults.forEach(result => console.log(result));

// 只有會游泳的鳥才會被要求游泳
const swimmingBirds: Swimmable[] = [penguin];
const swimmingResults = improvedManager.makeSwimmingBirdsSwim(swimmingBirds);
console.log("\n會游泳的鳥類:");
swimmingResults.forEach(result => console.log(result));

改進重點說明

  1. 合理的繼承結構:將鳥類分為 FlyingBirdSwimmingBird,避免強制所有鳥類都要實作飛行能力
  2. 介面隔離:使用 FlyableSwimmable 介面來定義特定能力,遵循介面隔離原則
  3. 行為一致性:所有子類別都能完美替換其父類別,不會產生意外的行為
  4. 型別安全:透過 TypeScript 的型別系統,確保只有具備相應能力的鳥類才會被要求執行相應動作

這時候我們可以發現,改進後的設計讓每個類別都能完美替換其父類別,而且不會產生執行時期的錯誤。這就是里氏替換原則的威力!

總結

里氏替換原則的核心概念提醒我們:

  1. 子類別必須完全相容於父類別:不能改變父類別定義的行為契約
  2. 不要強化前置條件:子類別不應該要求比父類別更嚴格的輸入條件
  3. 不要弱化後置條件:子類別必須至少提供父類別承諾的輸出品質
  4. 保持行為一致性:子類別的行為應該符合使用者對父類別的期待

需要注意的是,里氏替換原則不僅關乎技術實作,更關乎設計的合理性。當我們發現子類別需要拋出例外或是無法實作某些方法時,通常表示繼承結構需要重新思考。

值得一提的是,違反 LSP 的設計往往也會違反開放封閉原則,因為我們可能需要在使用子類別的地方加入額外的條件判斷。透過遵循 LSP,我們可以建立更穩固的多態系統。

綜合以上所述,我們成功透過重新設計繼承結構實踐了里氏替換原則,讓系統中的每個子類別都能安全地替換其父類別。

參考資料

上一篇
對擴展開放,對修改封閉 - 開放封閉原則 (Open-Closed Principle)
下一篇
不要強迫依賴不需要的功能 - 介面隔離原則 (Interface Segregation Principle)
系列文
程式碼門診:診斷壞味道、開出重構處方9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言